# imports and setups {{{1
import json
import subprocess
import sys
import re
import os
import os.path
import copy
import random
import string

SYSTEM_CONFIG_FILE_NAME = 'config.json'
USER_CONFIG_FILE_NAME = os.path.expanduser('~/.tbgs-config.json')
LOCAL_CONFIG_FILE_NAME = '.tbgs-config.json'
FILES_SEPARATOR = '--'

COLORS = [
    '\033[0;31m', # red
    '\033[0;32m', # green
    '\033[0;33m', # brown/orange
    '\033[0;34m', # blue
    '\033[0;35m', # purple
    '\033[0;36m', # cyan
    '\033[0;37m', # light gray
]
NO_COLOR = '\033[0m'
searchDotFiles = True

def randomFileName(firstSearchWord, fixedStrings, stringLength=12):
    lettersAndDigits = string.ascii_letters + string.digits
    searchWordForFileName = firstSearchWord
    if fixedStrings:
        searchWordForFileName = ''
    return '/tmp/grep_result_' + searchWordForFileName + '_' + ''.join(random.choice(lettersAndDigits) for i in range(stringLength))
    # TODO: filter out non-word char from firstSearchWord
    # return '/tmp/grep_result_' + firstSearchWord + '_' + ''.join(random.choice(lettersAndDigits) for i in range(stringLength))

def getGrepResultFilePath(firstSearchWord, fixedStrings):
    return randomFileName(firstSearchWord, fixedStrings, 12) + '.tbgs'

def getGrepResultVimrcFilePath(firstSearchWord, fixedStrings):
    return randomFileName(firstSearchWord, fixedStrings, 12) + '.vim'

# validators {{{1
def getContainingPair(searchWords):
    sortedSearchWords = sorted(searchWords, key=len)
    for indexA, a in enumerate(sortedSearchWords):
        for indexB, b in enumerate(sortedSearchWords):
            if (indexA < indexB and a in b):
                return [b, a]
    return []

# static config loaders and parsers {{{1
def loadUserConfig():
    try:
        with open(USER_CONFIG_FILE_NAME, 'r') as configFile:
            data = configFile.read()
        return json.loads(data)
    except ValueError as err:
        return {}
    except FileNotFoundError as err:
        return {}

def loadGlobalConfig():
    dir_path = os.path.dirname(os.path.realpath(__file__))
    with open(dir_path + '/' + SYSTEM_CONFIG_FILE_NAME, 'r') as configFile:
        data = configFile.read()
    try:
        return json.loads(data)
    except ValueError as err:
        print("config.json file wrong format:\n" + str(err))
        quit()

def loadLocalConfigs():
    directories = os.getcwd().split(r'/')
    localConfigs = []
    for r in range(len(directories), 0, -1):
        currentPath = '/'.join(directories[:r]) or '/'
        try:
            with open(currentPath + '/' + LOCAL_CONFIG_FILE_NAME, 'r') as localConfigFile:
                data = localConfigFile.read()
            localConfigs.insert(0, json.loads(data))
        except:
            continue
    return localConfigs

def merge(source, destination):
    for key, value in source.items():
        if isinstance(value, dict):
            # get node or create one
            node = destination.setdefault(key, {})
            merge(value, node)
        else:
            destination[key] = value
    return destination

def deepMergeObjects(objects):
    result = {}
    for obj in objects:
        for key, value in obj.items():
            if isinstance(value, dict) and key in result and isinstance(result[key], dict):
                result[key] = deepMergeObjects([result[key], value])
            elif isinstance(value, list) and key in result and isinstance(result[key], list):
                result[key].extend(value)
            else:
                result[key] = value
    return result
def cleanUpConfig(config):
    cleanedUpConfig = copy.deepcopy(config)
    if (cleanedUpConfig['pipeTo'] == 'vim' or cleanedUpConfig['pipeTo'] == 'nvim'):
        cleanedUpConfig['colorOutput'] = False
    return cleanedUpConfig

def getStaticConfig():
    configs = loadLocalConfigs()
    configs.insert(0, loadUserConfig())
    configs.insert(0, loadGlobalConfig())
    return deepMergeObjects(configs)

def formatArgumentsPerMaps(arguments, maps):
    for key in maps:
        matches = [mapIndex for mapIndex, argument in enumerate(arguments) if argument == key]
        if (len(matches) == 0):
            continue
        for mapIndex in reversed(matches):
            if (mapIndex > 0 and arguments[mapIndex - 1] == 'keyword'):
                continue
            else:
                del arguments[mapIndex]
                if isinstance(maps[key], dict):
                    value = maps[key]["value"]
                else:
                    value = maps[key]
                if isinstance(maps[key], dict) and maps[key]["trailing"]:
                    trailingArgument = []
                    for segmentIndex in range(len(value.split())):
                        trailingArgument.insert(mapIndex + segmentIndex, value.split()[segmentIndex])
                else:
                    for segmentIndex in range(len(value.split())):
                        arguments.insert(mapIndex + segmentIndex, value.split()[segmentIndex])
    if len(trailingArgument) > 0:
        return arguments + trailingArgument
    return arguments

# dynamic config parsers {{{1
def parseArgv(arguments):
    files = []
    arguments = copy.copy(sys.argv[1:])
    userConfigs = loadLocalConfigs()
    userConfigs.insert(0, loadUserConfig())
    userConfigs = deepMergeObjects(userConfigs)
    try:
        arguments = formatArgumentsPerMaps(arguments, userConfigs['maps'])
    except:
        arguments = arguments
    dynamicConfig = {}
    searchWords = []
    skipOne = False
    suppressPreFilter = False
    if FILES_SEPARATOR in arguments:
        suppressPreFilter = True
        i = arguments.index(FILES_SEPARATOR)
        files = ' '.join(arguments[i + 1:])
        arguments = arguments[:i]
    for index, argument in enumerate(arguments):
        if (skipOne):
            skipOne = False
            continue
        if (argument == 'case'):
            dynamicConfig['caseSensitive'] = True
        elif (argument == 'no-case'):
            dynamicConfig['caseSensitive'] = False
        elif (argument == 'color'):
            dynamicConfig['colorOutput'] = True
        elif (argument == 'no-color'):
            dynamicConfig['colorOutput'] = False
        elif (argument == 'recur'):
            dynamicConfig['recursive'] = True
        elif (argument == 'no-recur'):
            dynamicConfig['recursive'] = False
        elif (argument == 'no-dot'):
            global searchDotFiles
            searchDotFiles = False
        elif (argument == 'word'):
            dynamicConfig['word'] = True
        elif (argument == 'no-word'):
            dynamicConfig['word'] = False
        elif (argument == 'around'):
            dynamicConfig['around'] = arguments[index + 1]
            skipOne = True
        elif (argument == 'max-length'):
            dynamicConfig['maxLength'] = arguments[index + 1]
            skipOne = True
        elif (argument == 'keyword'):
            searchWords.append(arguments[index + 1])
            skipOne = True
        elif (argument == 'dry-run'):
            dynamicConfig['dryRun'] = True
        elif (argument == 'fixed-strings'):
            dynamicConfig['fixedStrings'] = True
        elif (argument == 'pipe-to'):
            dynamicConfig['pipeTo'] = arguments[index + 1]
            if (arguments[index + 1] != 'less' and arguments[index + 1] != 'none'):
                dynamicConfig['colorOutput'] = False
            skipOne = True
        elif (argument == 'exclude-file'):
            if ('excludeFiles' in dynamicConfig):
                dynamicConfig['excludeFiles'].append(arguments[index + 1])
            else:
                dynamicConfig['excludeFiles'] = [arguments[index + 1]]
            skipOne = True
        elif (argument == 'pre-filter'):
            dynamicConfig['preFilter'] = arguments[index + 1]
            skipOne = True
        else:
            searchWords.append(argument)
    if (len(searchWords) == 0):
        print("Error: There is no search word to search!")
        quit()
    containingPair = getContainingPair(searchWords)
    if (len(containingPair) != 0):
        print("Error: Search word \"" + containingPair[0] + "\" contains another search word \"" + containingPair[1] + "\"")
        quit()
    if (pipeTo in dynamicConfig and dynamicConfig['pipeTo'] != 'none' and dynamicConfig['pipeTo'] != 'less'):
        dynamicConfig['colorOutput'] = False
    preFilter = ''
    config = cleanUpConfig(deepMergeObjects([getStaticConfig(), dynamicConfig]))
    if suppressPreFilter and config['preFilter'] == 'mdfind':
        config['preFilter'] = 'none'
    elif config['fixedStrings'] and config['preFilter'] == 'mdfind':
        config['preFilter'] = 'none'
    elif config['recursive'] == False:
        config['preFilter'] = 'none'
    return (config, searchWords, files)

# grep params generators {{{1
def getCaseOptionString(config):
    return ("-i", "")[config['caseSensitive']]
def getRecurOptionString(config):
    return ("", "-R")[config['recursive']]
def getPatternString(files, config):
    if len(files):
        return files
    else:
        return ("*", "* .[^.]*")[searchDotFiles]
def getWordOptionString(config):
    return ("", "-w")[config['word']]
def getColorOptionString(config):
    return '--color=' + ("never", "always")[config['colorOutput']]
def getAroundString(config):
    around = config['around']
    return ("", f"-C {around}")[bool(int(around))]
def getExcludeDirsString(config):
    excludeDirs = config['excludeDirs']
    if (not excludeDirs or len(excludeDirs) == 0):
        return ''
    result = " --exclude-dir="
    if (len(excludeDirs) == 1):
        result += excludeDirs[0]
    else:
        result += '{'
        for index, excludeDir in enumerate(excludeDirs):
            result += excludeDir
            if (index < len(excludeDirs) - 1):
                result += ','
        result += '}'
    return result
def getExcludeFilesString(config):
    excludeFiles = config['excludeFiles']
    if (not excludeFiles or len(excludeFiles) == 0):
        return ''
    result = " --exclude="
    if (len(excludeFiles) == 1):
        result += excludeFiles[0]
    else:
        result += '{'
        for index, excludeFile in enumerate(excludeFiles):
            result += excludeFile
            if (index < len(excludeFiles) - 1):
                result += ','
        result += '}'
    return result
def getExcludeDirsString(config):
    excludeDirs = config['excludeDirs']
    if (not excludeDirs or len(excludeDirs) == 0):
        return ''
    result = " --exclude-dir="
    if (len(excludeDirs) == 1):
        result += excludeDirs[0]
    else:
        result += '{'
        for index, excludeDir in enumerate(excludeDirs):
            result += excludeDir
            if (index < len(excludeDirs) - 1):
                result += ','
        result += '}'
    return result
def getFixedStrings(config):
    fixedStrings = config['fixedStrings']
    if (fixedStrings):
        return '--fixed-strings'
    else:
        return ''

# apply color to string
def applyColorToWord(searchWord, index):
    if (index >= len(COLORS)):
        index = 0
    return COLORS[index] + searchWord + NO_COLOR

def applyColorToContent(searchWord, content, caseSensitive, index):
    regex = (re.compile(searchWord, re.I), re.compile(searchWord))[caseSensitive]
    matches = regex.findall(content)
    splits = regex.split(content)
    result = ''
    for i in range(len(matches)):
        result += splits[i] + applyColorToWord(matches[i], index)
    result += splits[-1]
    return result

def applyColorToSection(searchWord, lineNumberString, contentString, caseSensitive, index):
    lineNumberLines = lineNumberString.split('\n')
    contentStringLines = applyColorToContent(searchWord, contentString, caseSensitive, index).split('\n')
    result = ''
    for i in range(len(lineNumberLines)):
        result += lineNumberLines[i] + contentStringLines[i] + '\n'
    return result

# filters {{{1
def filterByLength(grepResultString, maxLength):
    lines = grepResultString.split('\n')
    result = []
    for line in lines:
        if len(line.strip()) == 0:
            continue
        parts = line.strip().split(':', 2)
        if len(parts) != 3:
            parts = line.strip().split('-', 2)
            if len(parts) != 3 and len(line) <= maxLength:
                result.append(line)
            elif len(parts) == 3 and len(parts[2]) <= maxLength:
                result.append(line)
            continue
        if len(parts) == 3 and len(parts[2]) <= maxLength:
            result.append(line)
    return '\n'.join(result)

def filterCurrentLine(grepResult, config, searchWords, index):
    caseSensitive = config['caseSensitive']
    word = config['word']
    def filter(searchWord, currentLine):
        if caseSensitive:
            actualSearchWord = searchWord
            actualCurrentLine = currentLine
        else:
            actualSearchWord = searchWord.lower()
            actualCurrentLine = currentLine.lower()
        if word:
            return re.search(rf'\b{re.escape(actualSearchWord)}\b', actualCurrentLine)
        else:
            return actualSearchWord in actualCurrentLine
    colorOutput = config['colorOutput']
    def forEverySearchWord(lines, searchWords, index):
        if (len(searchWords) == 0):
            return lines
        searchWord = searchWords[0]
        result = []
        for line in lines:
            if (len(line) == 0):
                continue
            contents = line.split(':', 2)
            if filter(searchWord, contents[2]):
                    if colorOutput:
                        result.append(contents[0] + ':' + contents[1] + ':' + applyColorToContent(searchWord, contents[2], caseSensitive, index))
                    else:
                        result.append(line)
        return forEverySearchWord(result, searchWords[1:], index + 1)
    lines = grepResult.split('\n')
    result = forEverySearchWord(lines, searchWords, index)
    return '\n'.join(result)

def filterAroundLine(grepResult, config, searchWords, firstSearchWord, index):
    caseSensitive = config['caseSensitive']
    colorOutput = config['colorOutput']
    def forEverySearchWord(sections, searchWords, index):
        if (len(searchWords) == 0):
            return sections
        searchWord = searchWords[0]
        def getRegexSearchEndIndex(regex, string):
            try:
                return regex.search(string).end()
            except:
                return len(string)
        def getContentStartIndex(line):
            return min(getRegexSearchEndIndex(re.compile(r':\d+:'), line),
                getRegexSearchEndIndex(re.compile(r'-\d+-'), line))
        filteredSections = []
        for section in sections:
            lines = section.split('\n')
            contentString = ''
            lineNumberString = ''
            for line in lines:
                contentString += line[getContentStartIndex(line):]
                contentString += '\n'
                lineNumberString += line[0:getContentStartIndex(line)]
                lineNumberString += '\n'
            if ((caseSensitive and searchWord in contentString) or
                (not caseSensitive and searchWord.lower() in contentString.lower())):
                if (colorOutput):
                    filteredSections.append(applyColorToSection(searchWord, lineNumberString, contentString, caseSensitive, index).strip())
                else:
                    filteredSections.append(section.strip())
        return forEverySearchWord(filteredSections, searchWords[1:], index + 1)
    sections = grepResult.split('\n--\n')
    result = forEverySearchWord(sections, searchWords, index)
    if int(config['maxLength']) > 0:
        maxLength = int(config['maxLength'])
        result = list(filter(lambda chunk: not any(len(line) > maxLength for line in chunk.split('\n')), result))
    return '\n--\n'.join(result)

# pipe to {{{1
def pipeTo(grepResultString, config, searchWord):
    grepResultFilePath = getGrepResultFilePath(searchWord, config['fixedStrings']);
    grepResultVimrcFilePath = getGrepResultVimrcFilePath(searchWord, config['fixedStrings']);
    if int(config['maxLength']) > 0:
        grepResultString = filterByLength(grepResultString, int(config['maxLength']))
    if config['pipeTo'] == 'none':
        print(grepResultString)
    elif config['pipeTo'] == 'less':
        with open(grepResultFilePath, 'w') as grepResultFile:
            grepResultFile.write(grepResultString)
        subprocess.call(['less', '-FRX', grepResultFilePath])
    else:
        if config['pipeTo'] == 'code':
            config['pipeTo'] = 'Visual Studio Code'
        elif config['pipeTo'] == 'intellij':
            config['pipeTo'] = 'intelliJ IDEA'
        elif config['pipeTo'] == 'chrome':
            config['pipeTo'] = 'Google Chrome'
        elif config['pipeTo'] == 'canary':
            config['pipeTo'] = 'Google Chrome Canary'
        elif config['pipeTo'] == 'mvim':
            config['pipeTo'] = 'MacVim'
        elif config['pipeTo'] == 'subl':
            config['pipeTo'] = 'Sublime Text'
        with open(grepResultFilePath, 'w') as grepResultFile:
            grepResultFile.write(grepResultString)
        with open(grepResultVimrcFilePath, 'w') as grepResultVimrcFile:
            grepResultVimrcFile.write('silent! filetype plugin on\n')
            grepResultVimrcFile.write('silent! syntax enable\n')
            grepResultVimrcFile.write('silent! set hlsearch\n')
            grepResultVimrcFile.write('silent! set nobackup\n')
            grepResultVimrcFile.write('silent! set noswapfile\n')
            grepResultVimrcFile.write('silent! set nomodeline\n')
            grepResultVimrcFile.write('silent! set ignorecase\n')
            grepResultVimrcFile.write('silent! set smartcase\n')
            grepResultVimrcFile.write('silent! g/^--$/normal 0d$\n')
            grepResultVimrcFile.write('silent! %s/' + searchWord + '//gn\n')
            grepResultVimrcFile.write('silent! normal 0gg\n')
            if config['pipeTo'] != 'vim':
                grepResultVimrcFile.write('function! OpenAllFilesInVimFunction()\n')
                grepResultVimrcFile.write('  %s/^\\([^:]*\\):\\d*:.*$/"\\1" \\\\/\n')
                grepResultVimrcFile.write('  sort u\n')
                grepResultVimrcFile.write('  $norm $x\n')
                grepResultVimrcFile.write('  0norm Iopen -a "' + config['pipeTo'] + '" \n')
                grepResultVimrcFile.write('  %!sh\n')
                grepResultVimrcFile.write('  normal u\n')
                grepResultVimrcFile.write('endfunction\n')
                grepResultVimrcFile.write('nnoremap <space>O :silent! call OpenAllFilesInVimFunction()<cr>\n')
                grepResultVimrcFile.write('function! OpenCurrentFileInVimFunction()\n')
                grepResultVimrcFile.write('  s/^\\([^:]*\\):\\d*:.*$/"\\1"/\n')
                grepResultVimrcFile.write('  norm Iopen -a "' + config['pipeTo'] + '" \n')
                grepResultVimrcFile.write('  .!sh\n')
                grepResultVimrcFile.write('  normal u\n')
                grepResultVimrcFile.write('endfunction\n')
                grepResultVimrcFile.write('nnoremap <space>o :silent! call OpenCurrentFileInVimFunction()<cr>\n')

        dir_path = os.path.dirname(os.path.realpath(__file__))
        if config['pipeTo'] != 'nvim':
            vim_command = '/Applications/MacVim.app/Contents/MacOS/Vim'
        else:
            vim_command = 'nvim'
        subprocessCallArgs = [
            vim_command,
            grepResultFilePath,
            '-c', 'so ' + grepResultVimrcFilePath,
        ]
        if config['pipeTo'] == 'vim' or config['pipeTo'] == 'nvim':
            subprocessCallArgs.append('-c')
            subprocessCallArgs.append(':so ' + dir_path + '/pipe-to-vim.vim')
        try:
            subprocess.call(subprocessCallArgs)
        except:
            subprocessCallArgs[0] = 'vim'
            subprocess.call(subprocessCallArgs)

# runner {{{1
def runner(config, searchWords, files):
    firstSearchWord = searchWords[0]
    grepResultFilePath = getGrepResultFilePath(firstSearchWord, config['fixedStrings'])
    grepResultVimrcFilePath = getGrepResultVimrcFilePath(firstSearchWord, config['fixedStrings'])
    if 'preFilter' in config and config['preFilter'] == 'mdfind':
        command = f"((mdfindResult=$(mdfind -onlyin . {firstSearchWord} 2> /dev/null" +\
            "| awk '{sub(\"^'$PWD'/\", \"\"); print}'); [[ -n $mdfindResult ]] && " +\
            f"grep -nIEs {getRecurOptionString(config)} {getCaseOptionString(config)}" +\
            f" {getColorOptionString(config)} {getAroundString(config)} {getWordOptionString(config)}" +\
            f" {getExcludeDirsString(config)} {getExcludeFilesString(config)}" +\
            f" {getFixedStrings(config)} '{firstSearchWord}' $mdfindResult" + f") || echo '') > {grepResultFilePath}"
    else:
        command = f"grep -nIEs {getRecurOptionString(config)} {getCaseOptionString(config)}" +\
            f" {getColorOptionString(config)} {getAroundString(config)} {getWordOptionString(config)}" +\
            f" {getExcludeDirsString(config)} {getExcludeFilesString(config)}" +\
            f" {getFixedStrings(config)} '{firstSearchWord}' {getPatternString(files, config)}" + f" > {grepResultFilePath}"
    if 'dryRun' in config and config['dryRun'] == True:
        print(command)
        return
    try:
        subprocess.call(['rm', '-rf', grepResultFilePath])
        subprocess.call(['rm', '-rf', grepResultVimrcFilePath])
        subprocess.call(['bash', '-c', command])
        with open(grepResultFilePath, 'r') as grepResultFile:
            grepResult = grepResultFile.read().strip()
    except:
        print("something went wrong!")
        quit()
    if config['pipeTo'] == 'file-name-only':
        print(grepResultFilePath)
    elif (len(searchWords) == 1):
        pipeTo(grepResult, config, firstSearchWord)
    elif (not config['around']):
        pipeTo(filterCurrentLine(grepResult, config, searchWords[1:], 1), config, firstSearchWord)
    else:
        pipeTo(filterAroundLine(grepResult, config, searchWords[1:], firstSearchWord, 1), config, firstSearchWord)

def main():
    arguments = copy.copy(sys.argv[1:])
    (config, searchWords, files) = parseArgv(arguments)
    runner(config, searchWords, files)

main()
